use std::{
  collections::HashMap,
  sync::{Arc, Once},
};

use base64::{Engine, prelude::BASE64_STANDARD};
use serde::Deserialize;
use serde_bytes::ByteBuf;
use serde_wasm_bindgen::from_value;
use takumi::{
  GlobalContext,
  image::load_from_memory,
  layout::{
    DEFAULT_DEVICE_PIXEL_RATIO, DEFAULT_FONT_SIZE, Viewport,
    node::{Node, NodeKind},
  },
  parley::{FontWeight, fontique::FontInfoOverride},
  rendering::{
    AnimationFrame, ImageOutputFormat, RenderOptionsBuilder, encode_animated_png,
    encode_animated_webp, render, write_image,
  },
  resources::{
    image::{ImageSource, load_image_source_from_bytes},
    task::FetchTaskCollection,
  },
};
use wasm_bindgen::prelude::*;

#[wasm_bindgen(typescript_custom_section)]
const TS_APPEND_CONTENT: &'static str = r#"
export type AnyNode = { type: string; [key: string]: any };

export type ByteBuf = Uint8Array | ArrayBuffer | Buffer;

export type RenderOptions = {
  /**
   * The width of the image. If not provided, the width will be automatically calculated based on the content.
   */
  width?: number,
  /**
   * The height of the image. If not provided, the height will be automatically calculated based on the content.
   */
  height?: number,
  /**
   * The format of the image.
   * @default "png"
   */
  format?: "png" | "jpeg" | "webp",
  /**
   * The quality of JPEG format (0-100).
   */
  quality?: number,
  /**
   * The resources fetched externally. You should collect the fetch tasks first using `collectNodeFetchTasks` and then pass the resources here.
   */
  fetchedResources?: Map<string, ByteBuf>,
  /**
   * Whether to draw debug borders.
   */
  drawDebugBorder?: boolean,
  /**
   * Defines the ratio resolution of the image to the physical pixels.
   * @default 1.0
   */
  devicePixelRatio?: number,
};

export type RenderAnimationOptions = {
  width: number,
  height: number,
  format?: "webp" | "apng",
  drawDebugBorder?: boolean,
};

export type FontDetails = {
  name?: string,
  data: ByteBuf,
  weight?: number,
  style?: "normal" | "italic" | "oblique",
};

export type Font = FontDetails | ByteBuf;
"#;

#[wasm_bindgen]
extern "C" {
  #[wasm_bindgen(typescript_type = "AnyNode")]
  #[derive(Debug)]
  pub type AnyNode;

  #[wasm_bindgen(typescript_type = "RenderOptions")]
  pub type RenderOptionsType;

  #[wasm_bindgen(typescript_type = "RenderAnimationOptions")]
  pub type RenderAnimationOptionsType;

  #[wasm_bindgen(typescript_type = "FontDetails")]
  pub type FontDetailsType;

  #[wasm_bindgen(typescript_type = "Font")]
  pub type FontType;

  #[wasm_bindgen(js_namespace = console, js_name = "error")]
  fn console_error(msg: String);
}

#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct RenderOptions {
  width: Option<u32>,
  height: Option<u32>,
  format: Option<ImageOutputFormat>,
  quality: Option<u8>,
  fetched_resources: Option<HashMap<Arc<str>, ByteBuf>>,
  draw_debug_border: Option<bool>,
  device_pixel_ratio: Option<f32>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RenderAnimationOptions {
  width: u32,
  height: u32,
  format: Option<AnimationOutputFormat>,
  draw_debug_border: Option<bool>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct FontDetails {
  name: Option<String>,
  data: ByteBuf,
  weight: Option<f64>,
  style: Option<FontStyle>,
}

#[derive(Deserialize)]
#[serde(untagged)]
enum Font {
  Object(FontDetails),
  Buffer(ByteBuf),
}

#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum AnimationOutputFormat {
  APng,
  WebP,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
enum FontStyle {
  Normal,
  Italic,
  Oblique,
}

#[wasm_bindgen]
pub struct AnimationFrameSource {
  node: AnyNode,
  #[wasm_bindgen(js_name = durationMs)]
  duration_ms: u32,
}

#[wasm_bindgen]
impl AnimationFrameSource {
  #[wasm_bindgen(constructor)]
  pub fn new(
    node: AnyNode,
    #[wasm_bindgen(js_name = durationMs)] duration_ms: u32,
  ) -> AnimationFrameSource {
    AnimationFrameSource { node, duration_ms }
  }
}

impl From<FontStyle> for takumi::parley::FontStyle {
  fn from(style: FontStyle) -> Self {
    match style {
      FontStyle::Italic => takumi::parley::FontStyle::Italic,
      FontStyle::Oblique => takumi::parley::FontStyle::Oblique(None),
      FontStyle::Normal => takumi::parley::FontStyle::Normal,
    }
  }
}

#[wasm_bindgen]
#[derive(Default)]
pub struct Renderer {
  context: GlobalContext,
}

#[wasm_bindgen]
impl Renderer {
  #[wasm_bindgen(constructor)]
  pub fn new() -> Renderer {
    panic_hook();

    Renderer::default()
  }

  /// @deprecated use `loadFont` instead.
  #[wasm_bindgen(js_name = loadFontWithInfo)]
  pub fn load_font_with_info(&mut self, font: FontType) {
    self.load_font(font)
  }

  #[wasm_bindgen(js_name = loadFont)]
  pub fn load_font(&mut self, font: FontType) {
    let input: Font = from_value(font.into()).unwrap();

    match input {
      Font::Buffer(buffer) => {
        self
          .context
          .font_context
          .load_and_store(&buffer, None, None)
          .unwrap();
      }
      Font::Object(details) => {
        self
          .context
          .font_context
          .load_and_store(
            &details.data,
            Some(FontInfoOverride {
              family_name: details.name.as_deref(),
              style: details.style.map(Into::into),
              weight: details.weight.map(|weight| FontWeight::new(weight as f32)),
              axes: None,
              width: None,
            }),
            None,
          )
          .unwrap();
      }
    }
  }

  #[wasm_bindgen(js_name = putPersistentImage)]
  pub fn put_persistent_image(&self, src: String, data: &[u8]) {
    self.context.persistent_image_store.insert(
      src,
      Arc::new(ImageSource::Bitmap(
        load_from_memory(data).unwrap().into_rgba8(),
      )),
    );
  }

  #[wasm_bindgen(js_name = clearImageStore)]
  pub fn clear_image_store(&self) {
    self.context.persistent_image_store.clear();
  }

  #[wasm_bindgen]
  pub fn render(&self, node: AnyNode, options: Option<RenderOptionsType>) -> Vec<u8> {
    let node: NodeKind = from_value(node.into()).unwrap();
    let options: RenderOptions = options
      .map(|options| from_value(options.into()).unwrap())
      .unwrap_or_default();

    self.render_internal(node, options)
  }

  fn render_internal(&self, node: NodeKind, options: RenderOptions) -> Vec<u8> {
    let fetched_resources = options
      .fetched_resources
      .map(|resources| {
        resources
          .into_iter()
          .map(|(url, buffer)| (url, load_image_source_from_bytes(&buffer).unwrap()))
          .collect()
      })
      .unwrap_or_default();

    let image = render(
      RenderOptionsBuilder::default()
        .viewport(Viewport {
          width: options.width,
          height: options.height,
          font_size: DEFAULT_FONT_SIZE,
          device_pixel_ratio: options
            .device_pixel_ratio
            .unwrap_or(DEFAULT_DEVICE_PIXEL_RATIO),
        })
        .draw_debug_border(options.draw_debug_border.unwrap_or_default())
        .fetched_resources(fetched_resources)
        .node(node)
        .global(&self.context)
        .build()
        .unwrap(),
    )
    .unwrap();

    let mut buffer = Vec::new();

    write_image(
      &image,
      &mut buffer,
      options.format.unwrap_or(ImageOutputFormat::Png),
      options.quality,
    )
    .unwrap();

    buffer
  }

  #[wasm_bindgen(js_name = "renderAsDataUrl")]
  pub fn render_as_data_url(&self, node: AnyNode, options: RenderOptionsType) -> String {
    let node: NodeKind = from_value(node.into()).unwrap();
    let options: RenderOptions = from_value(options.into()).unwrap();

    let format = options.format.unwrap_or(ImageOutputFormat::Png);
    let buffer = self.render_internal(node, options);

    let mut data_uri = String::new();

    data_uri.push_str("data:");
    data_uri.push_str(format.content_type());
    data_uri.push_str(";base64,");
    data_uri.push_str(&BASE64_STANDARD.encode(buffer));

    data_uri
  }

  #[wasm_bindgen(js_name = renderAnimation)]
  pub fn render_animation(
    &self,
    frames: Vec<AnimationFrameSource>,
    options: RenderAnimationOptionsType,
  ) -> Vec<u8> {
    let options: RenderAnimationOptions = from_value(options.into()).unwrap();

    let rendered_frames: Vec<AnimationFrame> = frames
      .into_iter()
      .map(|frame| {
        let node: NodeKind = from_value(frame.node.into()).unwrap();
        let duration_ms = frame.duration_ms;

        let image = render(
          RenderOptionsBuilder::default()
            .viewport((options.width, options.height).into())
            .node(node)
            .global(&self.context)
            .draw_debug_border(options.draw_debug_border.unwrap_or_default())
            .build()
            .unwrap(),
        )
        .unwrap();
        AnimationFrame::new(image, duration_ms)
      })
      .collect();

    let mut buffer = Vec::new();

    match options.format.unwrap_or(AnimationOutputFormat::WebP) {
      AnimationOutputFormat::WebP => {
        encode_animated_webp(&rendered_frames, &mut buffer, true, false, None).unwrap();
      }
      AnimationOutputFormat::APng => {
        encode_animated_png(&rendered_frames, &mut buffer, None).unwrap();
      }
    }

    buffer
  }
}

/// Collects the fetch task urls from the node.
#[wasm_bindgen(js_name = collectNodeFetchTasks)]
pub fn collect_node_fetch_tasks(node: AnyNode) -> Vec<String> {
  panic_hook();

  let node: NodeKind = from_value(node.into()).unwrap();

  let mut collection = FetchTaskCollection::default();

  node.collect_fetch_tasks(&mut collection);
  node.collect_style_fetch_tasks(&mut collection);

  collection
    .into_inner()
    .iter()
    .map(|task| task.to_string())
    .collect()
}

static PANIC_HOOK_ONCE: Once = Once::new();

fn panic_hook() {
  PANIC_HOOK_ONCE.call_once(|| {
    std::panic::set_hook(Box::new(|info| {
      let mut message = info.to_string();

      // https://github.com/rustwasm/console_error_panic_hook/blob/master/src/lib.rs#L119
      message.push_str("\r\n");

      console_error(message);
    }));
  });
}
